iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Software Development

手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫系列 第 2

Day 02:實作高併發 TCP Socket 伺服器與 Graceful Shutdown

  • 分享至 

  • xImage
  •  

寫資料庫引擎之前,網路層得先站穩。Redis 之所以快,底層事件模型佔了很大一部分;我雖然不用在 Go 裡手刻 epoll,但還是得先做出一個可以同時處理多個 client、關機時也不會亂收尾的 TCP Server。

今天先把 TCP Server 跟 Graceful Shutdown 做起來。這塊如果一開始偷懶,後面接 RESP parser 和命令分發時會很痛。


為什麼需要 Graceful Shutdown?

在生產環境中,伺服器可能隨時因為部署更新、作業系統維護或配置調整而重啟。如果直接強制終止進程(例如 kill -9),會造成以下問題:

  1. 處理中的請求被中斷:客戶端發送的命令只執行到一半,導致資料不一致。
  2. 網路資源洩漏:TCP 連線沒有正確關閉,佔用系統 socket 資源。
  3. 資料遺失:快取或緩衝區中的資料來不及寫入磁碟。

Graceful Shutdown 的核心流程是:

  1. 收到作業系統的關閉訊號(如 SIGINTSIGTERM)。
  2. 停止接收新的客戶端連線(關閉 Listener)。
  3. 主動關閉或等待現有的連線將當前請求處理完畢。
  4. 釋放所有系統資源,隨後安全結束程式。

程式碼實作:TCP 伺服器架構

我在 code/server/server.go 中實作伺服器,先把幾個最基本但不能少的欄位放進來:

1. 伺服器結構設計

我定義了 Server 結構,包含用於追蹤連線與控制併發的核心欄位:

type Server struct {
	addr       string
	listener   net.Listener
	wg         sync.WaitGroup      // 追蹤當前正在執行中的 Goroutine
	quit       chan struct{}       // 用於通知伺服器關閉的 Channel
	inShutdown int32               // 標記伺服器是否處於關閉狀態(原子操作)

	mu         sync.Mutex
	conns      map[net.Conn]struct{} // 追蹤所有活著的客戶端連線
}

這邊我一開始寫的時候忘了加 sync.Mutex 保護 map,連線一多直接 panic,查了半天才發現是併發讀寫 map 炸了,真的不能鐵齒。

2. 啟動與連線監聽

當呼叫 Start() 時,使用 Go 標準庫的 net.Listen 啟動 TCP 監聽。隨後進入一個無窮循環(Event Loop),不斷接收新的連線:

func (s *Server) Start() error {
	l, err := net.Listen("tcp", s.addr)
	if err != nil {
		return fmt.Errorf("監聽埠口失敗: %w", err)
	}
	s.listener = l
	log.Printf("Redis Clone 伺服器已啟動,監聽於 %s", s.addr)

	for {
		conn, err := s.listener.Accept()
		if err != nil {
			select {
			case <-s.quit:
				// 若是因為伺服器主動關閉導致 Accept 報錯,這屬於預期行為,直接退出
				return nil
			default:
			}
			log.Printf("接受連線失敗: %v", err)
			continue
		}

		s.wg.Add(1)
		go s.handleConnection(conn) // 啟動新 goroutine 處理連線
	}
}

3. 連線處理與追蹤

handleConnection 中,需要動態記錄連線的建立與關閉。這可以防止伺服器在 Graceful Shutdown 時漏掉部分連線:

func (s *Server) handleConnection(conn net.Conn) {
	defer s.wg.Done()
	defer conn.Close()

	if s.isShuttingDown() {
		return
	}

	// 追蹤連線
	s.trackConn(conn, true)
	defer s.trackConn(conn, false)

	log.Printf("新連線建立: %s", conn.RemoteAddr().String())

	// 暫時做個簡單的 Echo,隨後會在此處整合我們的 RESP 解析器
	buf := make([]byte, 1024)
	// ... 讀寫邏輯 ...
}

4. 實現 Graceful Shutdown 的核心機制

當關閉指令觸發時,Shutdown 函數會被呼叫,它執行以下關鍵步驟:

func (s *Server) Shutdown(ctx context.Context) error {
	// 1. 設定關閉標記,並關閉 quit channel 停止 Accept 循環
	atomic.StoreInt32(&s.inShutdown, 1)
	close(s.quit)

	var err error
	if s.listener != nil {
		err = s.listener.Close() // 關閉 Listener,此時 Accept 會立即返回錯誤
	}

	// 2. 主動關閉當前所有已建立的客戶端連線
	s.mu.Lock()
	for conn := range s.conns {
		conn.Close()
	}
	s.mu.Unlock()

	// 3. 等待所有連線處理goroutine 完成,或直到 Context 逾時
	c := make(chan struct{})
	go func() {
		s.wg.Wait()
		close(c)
	}()

	select {
	case <-ctx.Done():
		return ctx.Err() // 逾時退出,防止程式無限期卡死
	case <-c:
		log.Println("伺服器已安全關閉")
		return err
	}
}

程式入口與 OS 訊號監聽

code/main.go 中,使用 Go 的 os/signal 監聽來自作業系統的 SIGINT (Ctrl+C) 和 SIGTERM 訊號,並設定 5 秒的關閉安全時間:

func main() {
	srv := server.NewServer(":6379")

	go func() {
		if err := srv.Start(); err != nil {
			log.Fatalf("伺服器啟動失敗: %v", err)
		}
	}()

	// 監聽作業系統中斷訊號
	shutdownSignal := make(chan os.Signal, 1)
	signal.Notify(shutdownSignal, syscall.SIGINT, syscall.SIGTERM)

	sig := <-shutdownSignal
	log.Printf("接收到關閉訊號 (%v),正在安全關閉...", sig)

	// 設定 5 秒的關閉逾時時間
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Printf("伺服器關閉時發生錯誤: %v", err)
	} else {
		log.Println("伺服器安全退出")
	}
}

測試我們的 TCP 伺服器

我們可以編譯並執行伺服器:

cd code
go run main.go

打開另一個終端機,使用 nc (Netcat) 連線到 6379 埠口:

nc localhost 6379

當輸入任何字元,伺服器都會將其回傳。當在伺服器端按下 Ctrl + C,會看到以下日誌輸出,顯示伺服器正以 Graceful Shutdown 流程安全結束:

2026/08/11 22:18:00 Redis Clone 伺服器已啟動,監聽於 :6379
2026/08/11 22:18:05 新連線建立: 127.0.0.1:51234
^C2026/08/11 22:18:10 接收到關閉訊號 (interrupt),正在安全關閉...
2026/08/11 22:18:10 伺服器已安全關閉
2026/08/11 22:18:10 伺服器安全退出

跑起來看看

來驗收一下今天寫的 TCP Server!先在終端機啟動我們的伺服器:

$ go run ./code/main.go
# 預期輸出:Server is running on :6379 之類的 log

接著開另一個終端機視窗,用 nc 連上去看看:

$ nc localhost 6379

這時候回到原本的伺服器視窗,應該會看到有新連線進來:

# 預期輸出:Accepted connection from 127.0.0.1:xxxx

最後我們在伺服器視窗按 Ctrl+C 測試 Graceful Shutdown,看看伺服器有沒有把該關的關好:

# 預期輸出:
# Graceful shutdown starting...
# Waiting for active connections to close...
# Graceful shutdown completed.

一切順利!連線跟關機看起來都沒問題。

總結

今天先把 TCP Server 跑起來,也補了連線追蹤和關機收尾。sync.WaitGroup、原子標記、訊號監聽看起來都很基礎,但少一個就很容易關不乾淨。

明天換研究 RESP 協定。光看那些prefix byte就覺得會有不少細節要踩,明天見囉!


上一篇
Day 01:Redis Clone 專案規劃與 Go 開發環境初始化
下一篇
Day 03:RESP 協定介紹與 Parser 基礎架構設計
系列文
手刻 Redis:用 Go 從零打造高效能高併發的記憶體資料庫4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言